#!/usr/bin/env python3

# Copyright (c) 2020-2021 Fpemud <fpemud@sina.com>
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
# THE SOFTWARE.

import os
import enum
import chunk
import struct
import pathlib
from datetime import date
from io import BytesIO
from ..._util import Util, CopiedAndSymlinkedDir, IniFile
from ..._game import GameBase, GameSpec, MatchContext
from ..._game_straits import GameStraitForDos, GameStraitNeedConfigDir, GameStraitNeedCacheDir


class SimCity2000(GameBase, GameStraitForDos):

    def __init__(self, data_dir):
        GameBase.__init__(self, data_dir)

        self._addStrait(GameStraitNeedConfigDir())
        self._addStrait(GameStraitNeedCacheDir())

        self._spec = GameSpec()
        if True:
            self._spec.default_language = "en_US"
            self._spec.languages = [self._spec.default_language]
            self._spec.resolutions = ["640x480"]

        self._mainBatFn = "SC2000.BAT"
        self._scurkBinFn = "SCURK.COM"
        self._cdromFn = "SC2000SE.DAT"
        self._cfgFn = "SC2000.CFG"

    @property
    def spec(self):
        return self._spec

    @property
    def run_items(self):
        return ["main", "scurk"]

    def get_title(self, lang):
        # this string should be the standard game name, this string appears in SC2000.cfg
        return "SimCity 2000"

    def _prepare(self, context):
        self._context = context
        try:
            self._gameIniFile = _GameIniFile(os.path.join(self._context.config_dirpath, "game.ini"))

            # make all files (not directories) except self._mainBatFn and self._cfgFn readonly
            # this game has no capablility of modifying directories
            self._targetGameDirObj = CopiedAndSymlinkedDir(self._context.cache_dirpath, [self._dataDir])
            for fn in os.listdir(self._dataDir):
                if fn in [self._mainBatFn, self._cfgFn]:
                    self._targetGameDirObj.copy(fn)
                else:
                    self._targetGameDirObj.symlink(fn, recursive=True)
            self._targetGameDirObj.restore_from(self._context.config_dirpath, exclude=["game.ini"])
            self._targetGameDirObj.clear()

            self._cfgFile = _CfgFile(self._cfgFullfn)
            self._cfgFile.set_sfx_enabled(self._gameIniFile.get_option("sfx-enabled"))

            self._mainBatFile = _MainBatFile(self._mainBatFullfn)
            self._mainBatFile.set_intro_enabled(self._gameIniFile.get_option("intro-enabled"))

            self._openCityFiles = []
        except BaseException:
            self._cleanup(on_error=True)
            raise

    @MatchContext.edit_or_run
    def get_language(self):
        return self._spec.default_language

    @MatchContext.edit_or_run
    def is_intro_enabled(self):
        return self._gameIniFile.get_option("intro-enabled")

    @MatchContext.edit_or_run
    def is_sfx_enabled(self):
        # sound effect in this game is bad, we provide an option to disable it
        self._gameIniFile.get_option("sfx-enabled")

    @MatchContext.edit_or_run
    def set_language(self, language):
        assert language == self._spec.default_language

    @MatchContext.edit_or_run
    def set_intro_enabled(self, value):
        self._gameIniFile.set_option("intro-enabled", value)

    @MatchContext.edit_or_run
    def set_sfx_enabled(self, value):
        self._gameIniFile.set_option("sfx-enabled", value)
        self._cfgFile.set_sfx_enabled(value)

    @MatchContext.edit
    def list_saved_city_files(self):
        ret = []
        for fullfn in Util.glob(os.path.join(self._targetGameDirObj.path, "**"), recursive=True):
            if not (fullfn.endswith(".SC2") or fullfn.endswith(".sc2")):
                continue
            if os.path.islink(fullfn) or os.path.isdir(fullfn):
                continue
            relPath = fullfn[len(self._targetGameDirObj.path) + 1:]
            ret.append(relPath)
        return ret

    @MatchContext.edit
    def open_saved_city_file(self, filename):
        retObj = CityFile(self, filename, os.path.join(self._targetGameDirObj.path, filename))
        self._openCityFiles.append(filename)                                        # allows open one file multiple times simutanously
        return retObj

    @MatchContext.edit
    def delete_saved_city_file(self, filename):
        assert filename not in self._openCityFiles
        os.unlink(os.path.join(self._targetGameDirObj.path, filename))

    @MatchContext.edit
    def delete_all_backup_city_files(self):
        for fullfn in Util.glob(os.path.join(self._targetGameDirObj.path, "**"), recursive=True):
            if not (fullfn.endswith(".BAK") or fullfn.endswith(".bak")):
                continue
            if os.path.islink(fullfn) or os.path.isdir(fullfn):
                continue
            Util.forceDelete(fullfn)

    @MatchContext.run
    def apply_resolution(self, resolution):
        assert resolution in self._spec.resolutions

    @MatchContext.run
    def apply_sound_hardware_type(self, hw_type):
        self._cfgFile.set_sfx_hardware_type(hw_type)

    def _commit(self):
        self._gameIniFile.write()
        self._cfgFile.write()
        self._mainBatFile.write()

    def _getRunInfo(self, item):
        assert not self._context.edit_or_run

        if item == "main":
            return self.RunInfo(self._targetGameDirObj.path, "C:\\" + self._mainBatFn, cdrom_filepath=self._cdromFullfn)
        elif item == "scurk":
            return self.RunInfo(self._targetGameDirObj.path, "C:\\" + self._scurkBinFn, cdrom_filepath=self._cdromFullfn)
        else:
            assert False

    def _cleanup(self, on_error=False):
        if not on_error:
            self._gameIniFile.write()
            self._cfgFile.write()
            self._mainBatFile.write()

        if hasattr(self, "_openCityFiles"):
            assert len(self._openCityFiles) == 0
            del self._openCityFiles
        if hasattr(self, "_mainBatFile"):
            del self._mainBatFile
        if hasattr(self, "_cfgFile"):
            del self._cfgFile
        if hasattr(self, "_targetGameDirObj"):
            if not on_error:
                self._targetGameDirObj.backup_to(self._context.config_dirpath,
                                                 wildcards=["*"],
                                                 exclude_wildcards=[self._mainBatFn, self._cfgFn, "game.ini"])
            del self._targetGameDirObj
        if hasattr(self, "_gameIniFile"):
            del self._gameIniFile
        if hasattr(self, "_context"):
            del self._context

    @property
    def _mainBatFullfn(self):
        return os.path.join(self._targetGameDirObj.path, self._mainBatFn)

    @property
    def _scurkBinFullfn(self):
        return os.path.join(self._targetGameDirObj.path, self._scurkBinFn)

    @property
    def _cdromFullfn(self):
        return os.path.join(self._targetGameDirObj.path, self._cdromFn)

    @property
    def _cfgFullfn(self):
        return os.path.join(self._targetGameDirObj.path, self._cfgFn)


class CityFile:

    # from https://github.com/emtiu/sc2000tool and https://github.com/dfloer/SC2k-docs

    # FIXME:
    # Another utility allowed you to edit the neighbors of your city.
    # Another allowed you to create your own scenarios.

    # add hack doc:
    # https://www.pixcom.dk/cheats/Sc2000.htm

    # city files
    # https://community.simtropolis.com/files/category/70-city-files/
    # https://community.simtropolis.com/clubs/30-simcity-2000-resource-page/

    class ParseError(Exception):
        pass

    def __init__(self, parent, filename, path):
        self._chOrder = [
            'MISC', 'ALTM', 'XTER', 'XBLD', 'XZON', 'XUND', 'XTXT', 'XLAB', 'XMIC', 'XTHG', 'XBIT',
            'XTRF', 'XPLT', 'XVAL', 'XCRM', 'XPLC', 'XFIR', 'XPOP', 'XROG', 'XGRP', 'CNAM',
        ]
        self._chRaw = [
            'ALTM', 'CNAM',                    # these chunks are always uncompressed
        ]
        self._chOptional = [
            'CNAM',                            # these chunks may be missing, it's normal
        ]
        self._sportsTeamDict = {
            'Football': 251,
            'Baseball': 252,
            'Soccer': 253,
            'Cricket': 254,
            'Rugby': 255,
        }

        self._parent = parent
        self._filename = filename
        self._path = path

        self._SC2 = {}                # uncompressed data
        with open(self._path, "rb") as f:
            ui = struct.unpack('>4cI4c', f.read(12))
            if not (b''.join(ui[:4]) == b'FORM' and b''.join(ui[5:]) == b'SCDH'):
                raise self.ParseError("bad header: not a SimCity2000 SC2 file")
            if not ui[4] == os.fstat(f.fileno()).st_size - 8:
                raise self.ParseError("bad header: wrong file length in header")

            while True:
                inC = None
                try:
                    inC = chunk.Chunk(f, align=False)
                    cname = inC.getname().decode("iso8859-1")
                    data = inC.read()
                    if cname in self._chRaw:
                        self._SC2[cname] = data
                    else:
                        self._SC2[cname] = self._decompress(cname, data)
                        assert self._decompress(cname, self._compress(self._SC2[cname])) == self._SC2[cname]        # test self._decompress() and self._compress()
                    inC.close()
                except EOFError:
                    break
                finally:
                    if inC is not None:
                        inC.close()

        self._bChanged = False

    @property
    def path(self):
        return self._path

    @property
    def changed(self):
        return self._bChanged

    def get_city_name(self):
        buf = self._SC2.get('CNAM', None)
        return self._getLabel(0, buf) if buf is not None else "<none>"

    def get_year_founded(self):
        return self._getMiscNum(3, self._SC2['MISC'])

    def get_city_age(self):
        return self._getMiscNum(4, self._SC2['MISC'])

    def get_current_date(self):
        tDays = self.get_city_age()
        if tDays == 0:
            # FIXME: should change to assert?
            return None
        else:
            # 12 months per year, 25 days per month
            pYear = tDays // (12 * 25)
            pMonth = (tDays - pYear * (12 * 25)) // 25
            pDay = (tDays + 1) - (pYear * (12 * 25)) - (pMonth * 25)
            return date(self.get_year_founded() + pYear, pMonth + 1, pDay)

    def get_money(self):
        return self._getMiscNum(5, self._SC2['MISC'])

    def get_game_level(self):
        # FIXME
        assert False

    def get_city_status(self):
        # FIXME
        assert False

    def get_mayor_name(self):
        return self._getLabel(0, self._SC2['XLAB'])

    def get_game_speed(self):
        # FIXME
        assert False

    def is_auto_budget_enabled(self):
        # FIXME
        assert False

    def is_auto_goto_enabled(self):
        # FIXME
        assert False

    def is_music_enabled(self):
        # FIXME
        assert False

    def is_sound_enabled(self):
        # FIXME
        assert False

    def get_newspaper_choice(self):
        # FIXME
        assert False

    def get_sports_team_names(self):
        # FIXME
        ret = {}
        for t in self._sportsTeamDict.keys():
            ret[t] = self._getLabel(self._sportsTeamDict[t], self._SC2['XLAB'])
        return ret

    def set_city_name(self, city_name):
        city_name = city_name.encode("iso8859-1")
        if len(city_name) >= 32:
            raise ValueError("city name must be less than 32 characters")
        self._SC2['CNAM'] = struct.pack('B', len(city_name)) + city_name + b'\x00'*(32-len(city_name)-1)
        self._bChanged = True

    def set_mayor_name(self, mayor_name):
        if len(mayor_name.encode("iso8859-1")) >= 25:
            raise ValueError("mayor name must be less than 25 characters")
        self._SC2['XLAB'] = self._putLabel(0, mayor_name, self._SC2['XLAB'])
        self._bChanged = True

    def set_money(self, money):
        if not (2**31 * -1 <= money <= 2**31 - 1):
            raise ValueError("money must be in the range of -2,147,483,648 to 2,147,483,647")
        self._SC2['MISC'] = self._putMiscNum(5, money, self._SC2['MISC'])
        self._bChanged = True

    def set_sports_team_name(self, team_type, team_name):
        if len(team_name.encode("iso8859-1")) >= 25:
            raise ValueError("team name must be less than 25 characters")
        self._SC2['XLAB'] = self._putLabel(self._sportsTeamDict[team_type], team_name, self._SC2['XLAB'])
        self._bChanged = True

    def save(self):
        if not self._bChanged:
            return

        # generate file content
        outData = b'SCDH'
        for inCh in self._chOrder:
            try:
                thisChunk = self._SC2[inCh]
            except KeyError:
                if inCh not in self._chOptional:
                    raise
            if inCh not in self._chRaw:
                thisChunk = self._compress(thisChunk)
            outData += inCh.encode("iso8859-1")
            outData += struct.pack('>i', len(thisChunk))
            outData += thisChunk

        # write file
        os.rename(self._path, self._path[:-4] + ".BAK")
        with open(self._path, "wb") as f:
            f.write(b'FORM' + struct.pack('>i', len(outData)) + outData)

        self._bChanged = False

    def close(self):
        self._parent._openCityFiles.remove(self._filename)
        del self._bChanged
        del self._SC2
        del self._path
        del self._filename
        del self._parent

    def __enter__(self):
        return self

    def __exit__(self, type, value, traceback):
        self.close()

    @classmethod
    def _decompress(cls, cname, cData):
        # decompress simcity2000 custom run length compression
        with BytesIO() as outBuf:
            with BytesIO(cData) as inBuf:
                while True:
                    inB = inBuf.read(1)
                    if len(inB) == 0:
                        # detect end of data
                        break
                    inN = struct.unpack('B', inB)[0]
                    if inN < 128:
                        # simple data bytes follow
                        outBuf.write(inBuf.read(inN))
                    elif inN > 128:
                        # compressed (repeated) data bytes follow, minimum value of (inN - 127) should be 2
                        outBuf.writelines([inBuf.read(1)] * (inN - 127))
                    else:
                        # note: this function only be called when parsing city file so we can use cls.ParseError here
                        raise cls.ParseError("invalid compressed data in chunk %s" % (cname))
            return outBuf.getvalue()

    @staticmethod
    def _compress(Data):
        # compressing backwards for easier length handling
        with BytesIO() as outBuf:
            # walk through data (backwards)
            with BytesIO(Data[::-1]) as inBuf:
                while True:
                    refByte = inBuf.read(1)
                    assert refByte != b''           # be True only when len(Data) == 0
                    nextByte = inBuf.read(1)
                    if nextByte == refByte:
                        # byte matches next byte
                        repC = 1
                        while nextByte == refByte:
                            repC += 1
                            nextByte = inBuf.read(1)
                        if repC <= 128:
                            outBuf.write(struct.pack('cB', refByte, repC + 127))
                        elif repC > 128:
                            # tail first, because backwards
                            if (repC % 128) == 0:
                                # special case n*128 for n>1: no tail
                                pass
                            elif (repC % 128) == 1:
                                # special case n*128+1: no rep in tail
                                outBuf.write(struct.pack('cB', refByte, 1))
                            else:
                                outBuf.write(struct.pack('cB', refByte, (repC % 128) + 127))
                            # body after tail, because backwards
                            for i in range(0, repC // 128):
                                outBuf.write(struct.pack('cB', refByte, 255))
                        if nextByte == b'':
                            break
                        inBuf.seek(-1, 1)
                    else:
                        # byte differs from next byte
                        conC = 0
                        with BytesIO() as cBuf:
                            while nextByte != refByte:
                                cBuf.write(refByte)
                                conC += 1
                                refByte = nextByte
                                nextByte = inBuf.read(1)
                            cBuf.seek(0)
                            if (conC % 127) != 0:
                                outBuf.write(cBuf.read(conC % 127))
                                outBuf.write(struct.pack('B', conC % 127))
                            for i in range(0, conC // 127):
                                outBuf.write(cBuf.read(127))
                                outBuf.write(struct.pack('B', 127))
                        if refByte == b'' and nextByte == b'':
                            break
                        inBuf.seek(-2, 1)
            return outBuf.getvalue()[::-1]      # backwards, remember?

    @staticmethod
    def _getLabel(lID, inBuf):
        length = struct.unpack('B', inBuf[25*lID:25*lID+1])[0]
        ret = b''.join(struct.unpack('%ic' % (length), inBuf[25*lID+1:25*lID+1+length]))
        return ret.decode("iso8859-1")

    @staticmethod
    def _putLabel(lID, value, inBuf):
        value = value.encode("iso8859-1")
        return inBuf[:25*lID] + struct.pack('B', len(value)) + value + b'\x00'*(25-len(value)-1) + inBuf[25*lID+25:]

    @staticmethod
    def _getMiscNum(nID, inMisc):
        return struct.unpack('>i', inMisc[4*nID:4*nID+4])[0]

    @staticmethod
    def _putMiscNum(nID, value, inMisc):
        return inMisc[:4*nID] + struct.pack('>i', value) + inMisc[4*nID+4:]


class _GameIniFile:

    def __init__(self, filepath):
        self._iniFile = IniFile(filepath)
        self._iniFile.set_value_type_and_fallback("main", "intro-enabled", bool, True)
        self._iniFile.set_value_type_and_fallback("main", "sfx-enabled", bool, True)

    def get_option(self, option):
        return self._iniFile.get_option("main", option, None)

    def set_option(self, option, value):
        if value == self._iniFile.get_fallback("main", option):
            self._iniFile.remove_option("main", option)
        else:
            self._iniFile.set_option("main", option, None, value)

    def write(self):
        self._iniFile.write()

    def reset(self):
        self._iniFile.reset()


class _MainBatFile:

    def __init__(self, path):
        self._path = path
        self._buf = pathlib.Path(self._path).read_bytes()

    def is_intro_enabled(self):
        if b"rem INTRO" in self._buf:
            return False
        elif b"INTRO" in self._buf:
            return True
        else:
            assert False

    def set_intro_enabled(self, value):
        if value:
            if self.is_intro_enabled():
                return
            self._buf = self._buf.replace(b"rem INTRO", b"INTRO")
        else:
            if not self.is_intro_enabled():
                return
            self._buf = self._buf.replace(b"INTRO", b"rem INTRO")

    def write(self):
        Util.writeIfNeccessary(self._path, self._buf)


class _CfgFile:

    class VideoType(enum.Enum):
        SuperVga = "VESA Super VGA"
        PackardBell = "Packard Bell (VESA)"

    class AudioType(enum.Enum):
        NoMusic = "NO MUSIC"
        NoSound = "NO SOUND"
        AdLib = "Ad Lib"
        AdLibGold = "Ad Lib Gold"
        SoundBlasterPro = "Sound Blaster Pro"
        SoundBlaster16 = "Sound Blaster 16"

    def __init__(self, path):
        self._path = path

        # default values
        self._videoType = self.VideoType.SuperVga
        self._musicAudioType = self.AudioType.AdLib
        self._sfxAudioType = self.AudioType.SoundBlasterPro

        # config
        self._bSfx = None
        self._sndHwType = None

    def set_sfx_enabled(self, value):
        self._bSfx = value

    def set_sfx_hardware_type(self, hwType):
        self._sndHwType = eval("self.AudioType.%s" % (hwType))

    def write(self):
        data = {
            self.AudioType.NoMusic:         ("*",            "*",            "*",   "*", "*", "*"),        # music-driver, sfx-driver, address, irq, dma, drq
            self.AudioType.NoSound:         ("*",            "*",            "*",   "*", "*", "*"),
            self.AudioType.AdLib:           ("A32ADLIB.DLL", "A32ADLIB.DLL", "*",   "*", "*", "*"),
            self.AudioType.AdLibGold:       ("A32ALGDG.DLL", "A32ALGDG.DLL", "*",   "*", "*", "*"),
            self.AudioType.SoundBlasterPro: ("A32SBPDG.DLL", "A32SBPDG.DLL", "220", "7", "1", "*"),
            self.AudioType.SoundBlaster16:  ("A32SP2FM.DLL", "A32SBPDG.DLL", "220", "7", "1", "*"),
        }

        assert self._bSfx is not None
        if not self._bSfx:
            sfxAudioType = self.AudioType.NoSound
        else:
            if self._sndHwType is not None:
                sfxAudioType = self._sndHwType
            else:
                sfxAudioType = self._sfxAudioType

        buf = ""
        buf += "[SimCity 2000 Configuration File]\n"
        buf += "\n"
        buf += "[Video]\n"
        buf += "VIDEONAME=%s\n" % (self._videoType.value)
        buf += "VIDEOCARD=ve:31\n"
        buf += "\n"
        buf += "[Music]\n"
        buf += "MUSICNAME=%s\n" % (self._musicAudioType.value)
        buf += "MUSICDRIVER=%s\n" % (data[self._musicAudioType][0])
        buf += "MUSICADDRESS=%s\n" % (data[self._musicAudioType][2])
        buf += "MUSICIRQ=%s\n" % (data[self._musicAudioType][3])
        buf += "MUSICDMA=%s\n" % (data[self._musicAudioType][4])
        buf += "MUSICDRQ=%s\n" % (data[self._musicAudioType][5])
        buf += "\n"
        buf += "[Sound Effects]\n"
        buf += "SFXNAME=%s\n" % (sfxAudioType.value)
        buf += "SFXDRIVER=%s\n" % (data[sfxAudioType][1])
        buf += "SFXADDRESS=%s\n" % (data[sfxAudioType][2])
        buf += "SFXIRQ=%s\n" % (data[sfxAudioType][3])
        buf += "SFXDMA=%s\n" % (data[sfxAudioType][4])
        buf += "SFXDRQ=%s\n" % (data[sfxAudioType][5])
        buf += "\n"
        buf += "[MIDI Bank]\n"
        buf += "MUSICFILES=SB\n"        # use fixed value
        buf += "\n"
        buf += "[WillTV Path]\n"
        buf += "WILLTV=D:\\DOS\n"       # use fixed value

        buf = buf.replace("\n", "\r\n").encode("iso8859-1")
        Util.writeIfNeccessary(self._path, buf)
